export interface Rule {
	type: string;
}

export interface MessageRule extends Rule {
	message?: string;
}

export interface MinRule extends MessageRule {
	type: 'min';
	value: number;
}

export interface MaxRule extends MessageRule {
	type: 'max';
	value: number;
}

type ValueType = 'min' | 'max' | 'less' | 'more';
export interface ValueRule extends MessageRule {
	type: ValueType;
	value: number;
}

type DateType = 'before' | 'after' | 'begin' | 'end';
export interface DateRule extends MessageRule {
	type: DateType;
	value: string | Date;
}

export interface MismatchRule extends MessageRule {
	type: 'mismatch';
}

export interface EqualRule extends MessageRule {
	type: 'equal';
	value: boolean | number | string | Date;
}

export interface RangeRule extends MessageRule {
	type: 'range';
	min: number;
	max: number;
}

export interface BetweenRule extends MessageRule {
	type: 'between';
	begin: string | Date;
	end: string | Date;
}

export interface PatternRule extends MessageRule {
	type: 'pattern';
	regex: string | RegExp;
}

export interface NeedRule extends MessageRule {
	type: 'need';
	rule: PrimitiveRule | UnionRule | null;
}

export type CheckRule =
	| ValueRule
	| DateRule
	| MismatchRule
	| EqualRule
	| RangeRule
	| BetweenRule
	| PatternRule
	| NeedRule;

export interface AnyRule extends MessageRule {
	type: 'any';
	rules: (CheckRule | AllRule | NotRule)[];
}

export interface AllRule extends MessageRule {
	type: 'all';
	rules: (CheckRule | AnyRule | NotRule)[];
}

export interface NotRule extends MessageRule {
	type: 'not';
	rules: (CheckRule | AnyRule | AllRule)[];
}

export type LogicRule = AnyRule | AllRule | NotRule;

// ----------- PrimitiveRule
export interface ObjectTypeRule extends Rule {
	type: 'object';
	rules: (PropRule | MismatchRule)[];
}

export interface ArrayTypeRule extends Rule {
	type: 'array';
	rules: (MinRule | MaxRule | MismatchRule | LogicRule | ItemRule)[];
}

export interface StringTypeRule extends Rule {
	type: 'string';
	rules: (EqualRule | MinRule | MaxRule | PatternRule | MismatchRule | LogicRule)[];
}

export interface NumberTypeRule extends Rule {
	type: 'number';
	rules: (EqualRule | ValueRule | RangeRule | MismatchRule | LogicRule)[];
}

export interface BooleanTypeRule extends Rule {
	type: 'boolean';
	rules: (EqualRule | MismatchRule | LogicRule)[];
}

export interface DateTypeRule extends Rule {
	type: 'date';
	rules: (EqualRule | DateRule | BetweenRule | MismatchRule | LogicRule)[];
}

export interface UnknownTypeRule extends Rule {
	type: 'unknown';
	rules: never[];
}

export type PrimitiveRule =
	| UnknownTypeRule
	| ObjectTypeRule
	| ArrayTypeRule
	| StringTypeRule
	| NumberTypeRule
	| BooleanTypeRule
	| DateTypeRule;

export interface UnionRule extends Rule {
	type: 'union';
	rules: (PrimitiveRule | MismatchRule)[];
}

export type ValidRule = NeedRule | PrimitiveRule | UnionRule;

export interface ItemRule extends MessageRule {
	type: 'item';
	rule: ValidRule;
}

export interface PropRule extends MessageRule {
	type: 'prop';
	name: string | number;
	desc?: string;
	rule?: ValidRule;
}

export interface DescRule extends Rule {
	type: 'desc';
	text: string;
}

export interface Validator {
	(input: unknown, context: ValidateContext): void;
}

type RuleMaker = (rule: Rule, parentRule?: Rule) => Validator;

export class ValidationError extends Error {
	constructor(
		msg: string,
		private tracePath?: string[]
	) {
		super(msg);
	}
	trace(): string {
		return this.tracePath?.join('\n') ?? '';
	}
}

export class SchemaError extends Error {
	constructor(msg: string) {
		super(msg);
	}
}

function msg(e: unknown) {
	if (e instanceof Error) return e.message || `${e}`;
	else return `${e}`;
}

const RULES: { [key: string]: RuleMaker | null } = {
	mismatch: null,
	equal: equalRule as RuleMaker,
	min: minRule as RuleMaker,
	max: maxRule as RuleMaker,
	less: lessRule as RuleMaker,
	more: moreRule as RuleMaker,
	before: beforeRule as RuleMaker,
	after: afterRule as RuleMaker,
	begin: beginRule as RuleMaker,
	end: endRule as RuleMaker,
	range: rangeRule as RuleMaker,
	between: betweenRule as RuleMaker,
	pattern: patternRule as RuleMaker,
	any: anyRule as RuleMaker,
	all: allRule as RuleMaker,
	not: notRule as RuleMaker,
	need: needRule as RuleMaker,
	prop: propRule as RuleMaker,
	item: itemRule as RuleMaker,
	object: objectRule as RuleMaker,
	string: stringRule as RuleMaker,
	number: numberRule as RuleMaker,
	boolean: booleanRule as RuleMaker,
	date: dateRule as RuleMaker,
	array: arrayRule as RuleMaker,
	union: unionRule as RuleMaker,
	unknown: unknownRule as RuleMaker,
};

const PRIMITIVE: { [key: string]: RuleMaker } = {
	object: objectRule as RuleMaker,
	string: stringRule as RuleMaker,
	number: numberRule as RuleMaker,
	array: arrayRule as RuleMaker,
	boolean: booleanRule as RuleMaker,
	date: dateRule as RuleMaker,
};

function fill<T extends CheckRule>(info: T, message?: string | number | null): T {
	if (typeof message === 'string' || typeof message === 'number') {
		info.message = message.toString();
	}
	return info;
}

/**
 * Specified value, only be used as child rule of the primitive type string, number and boolean
 */
export function equal(value: number | string | boolean | Date, message?: string): EqualRule {
	return fill(
		{
			type: 'equal',
			value: value,
		},
		message
	);
}

/**
 * Specify minimum value or length, only be used as child rule of the primitive type
 */
export function min(value: number, message?: string): MinRule {
	return fill(
		{
			type: 'min',
			value: value,
		},
		message
	);
}

/**
 * Specify maximum value or length, only be used as child rule of the primitive type
 */
export function max(value: number, message?: string): MaxRule {
	return fill(
		{
			type: 'max',
			value: value,
		},
		message
	);
}

/**
 * Specify value or length's range, only be used as child rule of the primitive type
 */
export function range(min: number, max: number, message?: string): RangeRule {
	return fill(
		{
			type: 'range',
			min: min,
			max: max,
		},
		message
	);
}

export function less(value: number, message?: string): ValueRule {
	return fill(
		{
			type: 'less',
			value: value,
		},
		message
	);
}

export function more(value: number, message?: string): ValueRule {
	return fill(
		{
			type: 'more',
			value: value,
		},
		message
	);
}

export function before(value: Date | string, message?: string): DateRule {
	return fill(
		{
			type: 'before',
			value: value,
		},
		message
	);
}

export function after(value: Date | string, message?: string): DateRule {
	return fill(
		{
			type: 'after',
			value: value,
		},
		message
	);
}

export function begin(value: Date | string, message?: string): DateRule {
	return fill(
		{
			type: 'begin',
			value: value,
		},
		message
	);
}

/**
 * @param {string|Date} value
 * @param {string} message
 * @returns {ValueRule}
 */
export function end(value: Date | string, message?: string): DateRule {
	return fill(
		{
			type: 'end',
			value: value,
		},
		message
	);
}

/**
 * Specify date range, only be used as child rule of the date type
 */
export function between(begin: Date | string, end: Date | string, message?: string): BetweenRule {
	return fill(
		{
			type: 'between',
			begin: begin,
			end: end,
		},
		message
	);
}

/**
 * Specify string pattern, only be used as child rule of the string type
 */
export function pattern(regex: RegExp | string, message?: string): PatternRule {
	return fill(
		{
			type: 'pattern',
			regex: regex instanceof RegExp ? regex.toString() : regex,
		},
		message
	);
}

/**
 * Specify string pattern, only be used as child rule of the string type(pattern rule 's alias)
 */
export function match(regex: RegExp | string, message?: string): PatternRule {
	return fill(
		{
			type: 'pattern',
			regex: regex instanceof RegExp ? regex.toString() : regex,
		},
		message
	);
}

/**
 * Specify value must be provided, only be used as child rule of the primitive or group type
 * @param {string} message
 * @param  {...ComboRule} rules
 * @returns {NeedRule}
 */
export function need(rule: PrimitiveRule | UnionRule | string | null = null, message?: string): NeedRule {
	if (typeof rule === 'string') {
		message = rule;
		rule = null;
	}
	return fill(
		{
			type: 'need',
			rule: rule,
		},
		message
	);
}

/**
 * Show custom message when type mismatched, only be used as child rule of the primitive or group type
 */
export function mismatch(message?: string): MismatchRule {
	return fill(
		{
			type: 'mismatch',
		},
		message
	);
}

/**
 * Only be used as child rule of array type
 */
export function item(rule: ValidRule): ItemRule {
	return {
		type: 'item',
		rule: rule,
	};
}

/**
 * Only be used as child rule of object type
 */
export function prop(name: string | number, desc?: DescRule, rule?: ValidRule): PropRule;
export function prop(name: string | number, rule?: ValidRule, desc?: DescRule): PropRule;
export function prop(name: string | number, r1?: ValidRule | DescRule, r2?: ValidRule | DescRule): PropRule {
	function isRule(r?: ValidRule | DescRule): r is ValidRule {
		return !!r && (!!PRIMITIVE[r.type] || r.type === 'union' || r.type === 'need');
	}
	function isDesc(r?: ValidRule | DescRule): r is DescRule {
		return !!r && r.type === 'desc';
	}
	let rule: ValidRule | undefined;
	let desc: string | undefined;
	if (isRule(r1)) {
		rule = r1;
		if (isDesc(r2)) {
			desc = r2.text;
		}
	} else if (isDesc(r1)) {
		desc = r1.text;
		if (isRule(r2)) {
			rule = r2;
		}
	}
	return {
		type: 'prop',
		name,
		desc,
		rule,
	};
}

/**
 * Property description only be used as child rule of the prop rule
 */
export function desc(text: string): DescRule {
	return {
		type: 'desc',
		text,
	};
}

/**
 * That indicate validating should passed when any sub rule matched, can be used as top level rule
 */
export function any(...rules: (CheckRule | AllRule | NotRule)[]): AnyRule {
	return {
		type: 'any',
		rules: rules,
	};
}

/**
 * That indicate validating should passed when all sub rule matched, can be used as top level rule
 */
export function all(...rules: (CheckRule | AnyRule | NotRule)[]): AllRule {
	return {
		type: 'all',
		rules: rules,
	};
}

/**
 * That indicate validating should passed when sub rule not matched, can be used as top level rule
 */
export function not(...rules: (CheckRule | AnyRule | AllRule)[]): NotRule {
	return {
		type: 'not',
		rules: rules,
	};
}

/**
 * That indicate validating should passed when any sub rule matched, can be used as top level rule
 */
export function union(...rules: (PrimitiveRule | MismatchRule)[]): UnionRule {
	return {
		type: 'union',
		rules: rules,
	};
}

type SubRulesType<R extends PrimitiveRule> = R['rules'];

/**
 * Primitive type object, can be used as top level rule
 */
export function object(...rules: SubRulesType<ObjectTypeRule>): ObjectTypeRule {
	return {
		type: 'object',
		rules: rules,
	};
}

/**
 * Primitive type number, can be used as top level rule
 */
export function boolean(...rules: SubRulesType<BooleanTypeRule>): BooleanTypeRule {
	return {
		type: 'boolean',
		rules: rules,
	};
}

/**
 * Primitive type number, can be used as top level rule
 */
export function number(...rules: SubRulesType<NumberTypeRule>): NumberTypeRule {
	return {
		type: 'number',
		rules: rules,
	};
}

/**
 * Primitive type string, can be used as top level rule
 */
export function string(...rules: SubRulesType<StringTypeRule>): StringTypeRule {
	return {
		type: 'string',
		rules: rules,
	};
}

/**
 * Primitive type string, can be used as top level rule
 */
export function date(...rules: SubRulesType<DateTypeRule>): DateTypeRule {
	return {
		type: 'date',
		rules: rules,
	};
}

/**
 * Primitive type array, can be used as top level rule
 */
export function array(...rules: SubRulesType<ArrayTypeRule>): ArrayTypeRule {
	return {
		type: 'array',
		rules: rules,
	};
}

export function unknown(): UnknownTypeRule {
	return {
		type: 'unknown',
		rules: [],
	};
}

//////////////////////////////////////////////////

const VALUE_OPERATOR = { min: '>=', max: '<=', less: '<', more: '>' };
const DATE_OPERATOR = { begin: '>=', end: '<=', before: '<', after: '>' };

function getInputType(input: unknown): string {
	let inputType: string = typeof input;
	if (inputType === 'object' && input instanceof Array) {
		inputType = 'array';
	} else if (inputType === 'object' && input instanceof Date) {
		inputType = 'date';
	}
	return inputType;
}

function validate(input: unknown, validators: Validator[], context: ValidateContext) {
	validators.forEach((validator) => {
		validator(input, context);
	});
}

function createValidator(rule: Rule, availableRules: string[], parentRule?: Rule): Validator {
	if (!rule || !rule.type) {
		throw new SchemaError(`invalid rule: cannot be '${typeof rule}'`);
	}
	if (!availableRules.find((r) => r === rule.type)) {
		throw new SchemaError(`unexpected rule: '${rule.type}'`);
	}
	const maker = RULES[rule.type];
	if (maker) {
		return maker(rule, parentRule);
	} else {
		throw new SchemaError(`unexpected rule: '${rule.type}'`);
	}
}

function createSubValidators(
	parentRule: PrimitiveRule,
	subRules: (CheckRule | AllRule | AnyRule | NotRule | ItemRule | PropRule)[],
	validators: Validator[],
	...availableRules: string[]
): string | null {
	let mismatchMessage = null;
	const i = 0;
	const rules = subRules || [];
	try {
		rules.forEach((r) => {
			if (r.type === mismatch.name) {
				if (availableRules.find((ar) => ar === r.type)) {
					mismatchMessage = r.message;
				} else {
					throw new SchemaError(`unexpected rule: '${r.type}'`);
				}
			} else {
				validators.push(createValidator(r, availableRules, parentRule));
			}
		});
		return mismatchMessage || null;
	} catch (e) {
		throw new SchemaError(`invalid "${parentRule.type}" rule (sub rule ${i + 1}):\n    > ${msg(e)}`);
	}
}

/**
 * @throws ValidationError
 */
function invalidValueMessage(rule: ValueRule | DateRule, state: string, _: ValidateContext): void {
	let msg: string;
	if (rule.message) {
		msg = rule.message;
	} else {
		msg = `${state} ${rule.value}`;
	}
	throw new ValidationError(msg);
}

function equalRule(rule: ValueRule | DateRule, parentRule: Rule): Validator {
	function invalidType() {
		throw new SchemaError(`invalid "equal" rule: "value" property must be a valid ${parentRule.type}`);
	}
	const valType = getInputType(rule.value);
	if (parentRule.type === 'date') {
		if (valType === 'string') {
			if (isNaN(new Date(rule.value).getTime())) {
				invalidType();
			}
		} else if (parentRule.type !== valType) {
			invalidType();
		} else if (isNaN((rule.value as Date).getTime())) {
			invalidType();
		}
	} else if (parentRule.type !== valType || (valType === 'number' && isNaN(rule.value as number))) {
		invalidType();
	}
	return (input, context) => {
		if (parentRule.type === getInputType(input)) {
			let result: boolean;
			if (parentRule.type === 'date') {
				result = (input as Date).getTime() === new Date(rule.value).getTime();
			} else {
				result = input === rule.value;
			}
			context.log(() => `${JSON.stringify(input)} == ${JSON.stringify(rule.value)}: ${result}`);
			if (!result) {
				invalidValueMessage(rule, 'must be equal to', context);
			}
		}
	};
}

interface Comparator<T> {
	(input: T, targetValue: T): boolean;
}

function valueRuleBase(rule: ValueRule, parentRule: Rule, valid: Comparator<number>, msg: string): Validator {
	if (!(typeof rule.value === 'number') || isNaN(rule.value)) {
		throw new SchemaError(`invalid "${rule.type}" rule: "value" property must be a valid number`);
	}
	return (input, context) => {
		if (parentRule.type === 'number') {
			const result = valid(input as number, rule.value) && !isNaN(input as number);
			context.log(() => `value ${input} ${VALUE_OPERATOR[rule.type]} ${rule.value}: ${result}`);
			if (!result) {
				invalidValueMessage(rule, `value should ${msg}`, context);
			}
		} else if (parentRule.type === 'string') {
			const result = valid((input as string).length, rule.value);
			context.log(() => `length ${(input as string).length} ${VALUE_OPERATOR[rule.type]} ${rule.value}: ${result}`);
			if (!result) {
				invalidValueMessage(rule, `length should ${msg}`, context);
			}
		} else if (parentRule.type === 'array') {
			const result = valid((input as unknown[]).length, rule.value);
			context.log(() => `size ${(input as unknown[]).length} ${VALUE_OPERATOR[rule.type]} ${rule.value}: ${result}`);
			if (!result) {
				invalidValueMessage(rule, `size should ${msg}`, context);
			}
		}
	};
}

function minRule(rule: ValueRule, parentRule: Rule): Validator {
	return valueRuleBase(rule, parentRule, (input, targetValue) => input >= targetValue, '>=');
}

function maxRule(rule: ValueRule, parentRule: Rule): Validator {
	return valueRuleBase(rule, parentRule, (input, targetValue) => input <= targetValue, '<=');
}

function lessRule(rule: ValueRule, parentRule: Rule): Validator {
	return valueRuleBase(rule, parentRule, (input, targetValue) => input < targetValue, '<');
}

function moreRule(rule: ValueRule, parentRule: Rule): Validator {
	return valueRuleBase(rule, parentRule, (input, targetValue) => input > targetValue, '>');
}

function dateRuleBase(rule: DateRule, valid: Comparator<number>, msg: string): Validator {
	function invalidType() {
		throw new SchemaError(`invalid "${rule.type}" rule: "value" property must be a valid date`);
	}
	const valType = getInputType(rule.value);
	if (valType === 'string') {
		if (isNaN(new Date(rule.value).getTime())) {
			invalidType();
		}
	} else if (valType !== 'date' || isNaN((rule.value as Date).getTime())) {
		invalidType();
	}
	return (input, context) => {
		const inputType = getInputType(input);
		if (inputType === 'date') {
			const result = valid((input as Date).getTime(), new Date(rule.value).getTime());
			context.log(
				() => `date ${JSON.stringify(input)} ${DATE_OPERATOR[rule.type]} ${JSON.stringify(rule.value)}: ${result}`
			);
			if (!result) {
				invalidValueMessage(rule, `date should ${msg}`, context);
			}
		}
	};
}

function beforeRule(rule: DateRule, _: Rule): Validator {
	return dateRuleBase(rule, (input, targetValue) => input < targetValue, 'before');
}

function afterRule(rule: DateRule, _: Rule): Validator {
	return dateRuleBase(rule, (input, targetValue) => input > targetValue, 'after');
}

function beginRule(rule: DateRule, _: Rule): Validator {
	return dateRuleBase(rule, (input, targetValue) => input >= targetValue, 'starts at');
}

function endRule(rule: DateRule, _: Rule): Validator {
	return dateRuleBase(rule, (input, targetValue) => input <= targetValue, 'ends on');
}

function rangeRule(rule: RangeRule, _: Rule): Validator {
	if (!(typeof rule.min === 'number') || isNaN(rule.min)) {
		throw new SchemaError('invalid "range" rule: "min" property must be a valid number');
	}
	if (!(typeof rule.max === 'number') || isNaN(rule.max)) {
		throw new SchemaError('invalid "range" rule: "max" property must be a valid number');
	}
	if (rule.min >= rule.max) {
		throw new SchemaError('invalid "range" rule: "min" property must be less or equal to "max" property');
	}
	return (input, context) => {
		const inputType = getInputType(input);
		if (inputType === 'number') {
			const result = (input as number) >= rule.min && (input as number) <= rule.max && !isNaN(input as number);
			context.log(() => `value ${input} between ${rule.min} and ${rule.max}: ${result}`);
			if (!result) {
				if (rule.message) {
					throw new ValidationError(rule.message);
				} else {
					throw new ValidationError(`value should in [${rule.min} to ${rule.max}]`);
				}
			}
		}
	};
}

function betweenRule(rule: BetweenRule, _: Rule): Validator {
	function invalidType(prop: string): never {
		throw new SchemaError(`invalid "between" rule: "${prop}" property must be a valid date`);
	}
	function checkProp(prop: 'begin' | 'end'): Date {
		if (!(rule[prop] instanceof Date)) {
			if (typeof rule[prop] === 'string') {
				const dt = new Date(rule[prop]);
				if (isNaN(dt.getTime())) {
					invalidType(prop);
				} else {
					return dt;
				}
			} else {
				invalidType(prop);
			}
		} else {
			if (isNaN((rule[prop] as Date).getTime())) {
				invalidType(prop);
			} else {
				return rule[prop] as Date;
			}
		}
	}
	const begin = checkProp('begin');
	const end = checkProp('end');
	if (begin.getTime() >= end.getTime()) {
		throw new SchemaError('invalid "between" rule: "begin" property must be ealier or equal to "end" property');
	}
	return (input, context) => {
		const inputType = getInputType(input);
		if (inputType === 'date') {
			const result = (input as Date).getTime() >= begin.getTime() && (input as Date).getTime() <= end.getTime();
			context.log(
				() =>
					`date ${JSON.stringify(input)} between ${JSON.stringify(rule.begin)} and ${JSON.stringify(
						rule.end
					)}: ${result}`
			);
			if (!result) {
				if (rule.message) {
					throw new ValidationError(rule.message);
				} else {
					throw new ValidationError(
						`date should between [${JSON.stringify(rule.begin)} and ${JSON.stringify(rule.end)}]`
					);
				}
			}
		}
	};
}

const PATTERN_ERR_MSG = 'invalid "pattern" rule: must provide a valid RegExp object or regular expression string';

const SHOULD = 'should meet condition: ';

function patternRule(rule: PatternRule, _: Rule): Validator {
	let exp: RegExp;
	if (rule.regex instanceof RegExp) {
		exp = rule.regex;
	} else if (typeof rule.regex === 'string') {
		try {
			const m = rule.regex.match(/^\/(.+)\/(.*)$/);
			if (!m) {
				throw new SchemaError(PATTERN_ERR_MSG);
			}
			const pat = m[1];
			const flag = m[2];
			exp = new RegExp(pat, flag || undefined);
		} catch (e) {
			throw new SchemaError(`${PATTERN_ERR_MSG}: ${msg(e)}`);
		}
	} else {
		throw new SchemaError(PATTERN_ERR_MSG);
	}
	return (input, context) => {
		if (typeof input === 'string') {
			const result = exp.test(input);
			context.log(() => `string ${JSON.stringify(input)} match ${rule.regex}: ${result}`);
			if (!result) {
				if (rule.message) {
					throw new ValidationError(rule.message);
				} else {
					throw new ValidationError(`${SHOULD}${exp.toString()}`);
				}
			}
		}
	};
}

function logicValidators(
	validators: Validator[],
	rules: (CheckRule | AllRule | AnyRule | NotRule)[],
	parentRule: PrimitiveRule,
	extraRules: string[]
) {
	let availableRules: string[];
	if (parentRule.type === string.name) {
		availableRules = [equal.name, min.name, max.name, pattern.name, ...extraRules];
	} else if (parentRule.type === number.name) {
		availableRules = [equal.name, min.name, max.name, less.name, more.name, range.name, ...extraRules];
	} else if (parentRule.type === boolean.name) {
		availableRules = [equal.name, ...extraRules];
	} else if (parentRule.type === date.name) {
		availableRules = [equal.name, begin.name, end.name, before.name, after.name, between.name, ...extraRules];
	} else availableRules = [];
	createSubValidators(parentRule, rules, validators, ...availableRules);
}

function anyRule(rule: AnyRule, parentRule: PrimitiveRule): Validator {
	const validators: Validator[] = [];
	logicValidators(validators, rule.rules, parentRule, [all.name, not.name]);
	if (validators.length < 2) {
		throw new SchemaError('invalid "any" rule: at least provide two sub rules');
	}
	return (input, context) => {
		let result = false;
		const pos = context.path.length;
		context.level++;
		for (let i = 0; i < validators.length; i++) {
			try {
				validators[i](input, context);
				result = true;
				break;
			} catch {
				// do nothing
			}
		}
		context.level--;
		context.log(() => `match any: ${result}`, pos);
		if (!result) {
			throw new ValidationError(SHOULD + description(rule, typeof input));
		}
	};
}

function allRule(rule: AllRule, parentRule: PrimitiveRule): Validator {
	const validators: Validator[] = [];
	logicValidators(validators, rule.rules, parentRule, [any.name, not.name]);
	if (validators.length < 2) {
		throw new SchemaError('invalid "all" rule: at least provide two sub rules');
	}
	return (input, context) => {
		let result = true;
		const pos = context.path.length;
		try {
			context.level++;
			validators.forEach((v) => v(input, context));
		} catch {
			result = false;
		} finally {
			context.level--;
			context.log(() => `match all: ${result}`, pos);
		}
		if (!result) {
			throw new ValidationError(SHOULD + description(rule, typeof input));
		}
	};
}

function notRule(rule: NotRule, parentRule: PrimitiveRule): Validator {
	const validators: Validator[] = [];
	logicValidators(validators, rule.rules, parentRule, [any.name, all.name]);
	return (input, context) => {
		let result = false;
		const pos = context.path.length;
		try {
			context.level++;
			validators.forEach((v) => v(input, context));
		} catch {
			result = true;
		} finally {
			context.level--;
			context.log(() => `not: ${result}`, pos);
		}
		if (!result) {
			throw new ValidationError(SHOULD + description(rule, typeof input));
		}
	};
}

function propRule(rule: PropRule): Validator {
	if (!rule.name && rule.name !== 0) {
		throw new SchemaError('invalid "prop" rule: "name" must be a valid key or index');
	}
	if (!rule.rule || typeof rule.rule !== 'object' || typeof rule.rule.type !== 'string') {
		return () => {
			/* do nothing */
		};
	}
	let validator: Validator;
	try {
		validator = createValidator(rule.rule, [
			need.name,
			object.name,
			string.name,
			number.name,
			array.name,
			boolean.name,
			date.name,
			union.name,
			unknown.name,
		]);
	} catch (e) {
		throw new SchemaError(`invalid "prop" rule of "${rule.name}":\n    > ${msg(e)}`);
	}
	return (input, context) => {
		let result = true;
		const pos = context.path.length;
		try {
			context.level++;
			if (typeof input === 'object' && input !== null) {
				validator((input as { [key: string]: unknown })[rule.name], context);
			}
		} catch (e) {
			result = false;
			throw new ValidationError(`invalid property "${rule.name}": ${msg(e)}`);
		} finally {
			context.level--;
			context.endBlock(`property ${JSON.stringify(rule.name)}`, result, pos);
		}
	};
}

function itemRule(rule: ItemRule): Validator {
	if (!rule.rule || typeof rule.rule !== 'object' || typeof rule.rule.type !== 'string') {
		throw new SchemaError('invalid "item" rule: must provide a sub rule');
	}
	let validator: Validator;
	try {
		validator = createValidator(rule.rule, [
			need.name,
			object.name,
			string.name,
			number.name,
			array.name,
			boolean.name,
			date.name,
			union.name,
			unknown.name,
		]);
	} catch (e) {
		throw new SchemaError(`invalid "item" rule:\n    > ${msg(e)}`);
	}
	return (input, context) => {
		let i = 0;
		let result = true;
		const pos = context.path.length;
		if (input instanceof Array) {
			for (i = 0; i < input.length; i++) {
				try {
					context.level++;
					validator(input[i], context);
				} catch (e) {
					result = false;
					throw new ValidationError(`invalid item [${i}]: ${msg(e)}`);
				} finally {
					context.level--;
					context.endBlock(`item[${i}]`, result, pos);
				}
			}
		}
	};
}

function needRule(rule: NeedRule): Validator {
	let validator: Validator;
	if (rule.rule) {
		try {
			validator = createValidator(rule.rule, [
				object.name,
				string.name,
				number.name,
				array.name,
				boolean.name,
				date.name,
				union.name,
				unknown.name,
			]);
		} catch (e) {
			throw new SchemaError(`invalid "need" rule:\n    > ${msg(e)}`);
		}
	}
	return (input, context) => {
		let result = false;
		const pos = context.path.length;
		try {
			if (typeof input === 'undefined' || input === null) {
				if (rule.message) {
					throw new ValidationError(rule.message);
				} else {
					throw new ValidationError('value is need');
				}
			}
			context.level++;
			validator(input, context);
			result = true;
		} finally {
			context.level--;
			context.endBlock('need', result, pos);
		}
	};
}

function createPrimitiveRule(rule: PrimitiveRule, ...availableRules: string[]): Validator {
	const validators: Validator[] = [];
	const mismatchMessage = createSubValidators(rule, rule.rules, validators, ...availableRules);
	function throwMismatch(inputType: string): never {
		if (mismatchMessage) {
			throw new ValidationError(mismatchMessage);
		} else {
			throw new ValidationError(`unexpected type: require '${rule.type}' but found '${inputType}'`);
		}
	}
	return (input, context) => {
		const inputType = getInputType(input);
		if (inputType !== 'undefined' && input !== null) {
			if (rule.type === 'unknown') {
				return;
			}
			if (rule.type === 'date' && inputType === 'string') {
				input = new Date(input as string | Date);
				if (isNaN((input as Date).getTime())) {
					throwMismatch(inputType);
				}
			} else if (context.lenientMode && rule.type === 'number' && inputType === 'string') {
				input = Number(input);
				if (isNaN(input as number)) {
					throwMismatch(inputType);
				}
			} else if (context.lenientMode && rule.type === 'boolean' && inputType === 'string') {
				if (input === 'true') {
					input = true;
				} else if (input === 'false') {
					input = false;
				} else {
					throwMismatch(inputType);
				}
			} else if (inputType !== rule.type) {
				throwMismatch(inputType);
			} else if (inputType === 'date' && isNaN((input as Date).getTime())) {
				throwMismatch('invalid date');
			} else if (inputType === 'number' && isNaN(input as number)) {
				throwMismatch('invalid number');
			}
			let result = false;
			const pos = context.path.length;
			try {
				context.level++;
				validate(input, validators, context);
				result = true;
			} finally {
				context.level--;
				context.endBlock(rule.type, result, pos);
			}
		}
	};
}

function objectRule(rule: PrimitiveRule): Validator {
	return createPrimitiveRule(rule, prop.name, mismatch.name);
}

function stringRule(rule: PrimitiveRule): Validator {
	return createPrimitiveRule(
		rule,
		equal.name,
		min.name,
		max.name,
		pattern.name,
		any.name,
		all.name,
		not.name,
		mismatch.name
	);
}

function numberRule(rule: PrimitiveRule): Validator {
	return createPrimitiveRule(
		rule,
		equal.name,
		min.name,
		max.name,
		less.name,
		more.name,
		range.name,
		any.name,
		all.name,
		not.name,
		mismatch.name
	);
}

function arrayRule(rule: PrimitiveRule): Validator {
	return createPrimitiveRule(rule, min.name, max.name, item.name, any.name, all.name, not.name, mismatch.name);
}

function booleanRule(rule: PrimitiveRule): Validator {
	return createPrimitiveRule(rule, equal.name, any.name, all.name, not.name, mismatch.name);
}

function unknownRule(rule: UnknownTypeRule): Validator {
	return createPrimitiveRule(rule);
}

function dateRule(rule: PrimitiveRule): Validator {
	return createPrimitiveRule(
		rule,
		equal.name,
		begin.name,
		end.name,
		before.name,
		after.name,
		between.name,
		any.name,
		all.name,
		not.name,
		mismatch.name
	);
}

const UNION = { object, string, number, array, boolean, date, unknown, mismatch };

function unionRule(rule: UnionRule): Validator {
	const typeValidators: { type: string; validator: Validator }[] = [];
	const rules = rule.rules || [];
	try {
		rules.forEach((r) => {
			if (!r || !r.type) {
				throw new SchemaError(`invalid rule: cannot be '${typeof r}'`);
			}
			if (!(r.type in UNION)) {
				throw new SchemaError(`unexpected rule: ${r.type}`);
			}
		});
		rules
			.filter((r) => r.type in PRIMITIVE)
			.forEach((r) => {
				typeValidators.push({
					type: r.type,
					validator: PRIMITIVE[r.type](r),
				});
			});
	} catch (e) {
		throw new SchemaError(`invalid "union" rule:\n    > ${msg(e)}`);
	}
	const mismatchRule = rules.find((r): r is MismatchRule => r.type === mismatch.name);
	const mismatchMessage = mismatchRule ? mismatchRule.message : null;
	if (typeValidators.length < 2) {
		throw new SchemaError('invalid "union" rule, at least provide two subtype rules');
	}

	return (input, context) => {
		const inputType = getInputType(input);
		if (inputType !== 'undefined' && input !== null) {
			const validators = typeValidators
				.filter((v) => {
					return (
						v.type === inputType ||
						(v.type === 'date' && inputType === 'string' && !isNaN(new Date(input as string).getTime()))
					);
				})
				.map((v) => v.validator);
			if (validators.length == 0) {
				if (mismatchMessage) {
					throw new ValidationError(mismatchMessage);
				} else {
					const supportTypes = Array.from(new Set(typeValidators.map((v) => v.type)));
					throw new ValidationError(
						`unexpected type: can be '${supportTypes.join("' or '")}' but found '${inputType}'`
					);
				}
			}
			const errors = [];
			const pos = context.path.length;
			try {
				context.level++;
				for (const validator of validators) {
					try {
						validator(input, context);
						return;
					} catch (e) {
						errors.push(`${msg(e)}`);
					}
				}
			} finally {
				context.level--;
				context.endBlock('union', errors.length === 0, pos);
			}
			if (errors.length === 1) {
				throw new ValidationError(errors[0]);
			} else {
				const s = errors.map((e, i) => `rule ${i + 1}: ${e}`).join('; ');
				throw new ValidationError(`no rule matched: [${s}]`);
			}
		}
	};
}

export function description(
	r: AnyRule | AllRule | NotRule | CheckRule,
	valueType: string,
	not = false,
	parentIsAnd = false
): string {
	function bracket(msg: string): string {
		if (parentIsAnd) return `(${msg})`;
		return msg;
	}
	function getPrefix(type: string): string {
		if (valueType === 'string') {
			if (type !== 'equal') {
				return 'length';
			} else {
				return 'string';
			}
		} else if (valueType === 'array') {
			return 'size';
		} else {
			return 'value';
		}
	}
	if (r.type === 'all') {
		if (not) {
			return bracket(r.rules.map((sr) => description(sr, valueType, not)).join(' || '));
		} else {
			return r.rules.map((sr) => description(sr, valueType, not, true)).join(' && ');
		}
	} else if (r.type === 'any') {
		if (not) {
			return r.rules.map((sr) => description(sr, valueType, not, true)).join(' && ');
		} else {
			return bracket(r.rules.map((sr) => description(sr, valueType, not, false)).join(' || '));
		}
	} else if (r.type === 'not') {
		if (not) {
			return r.rules.map((sr) => description(sr, valueType, !not, true)).join(' && ');
		} else {
			return bracket(r.rules.map((sr) => description(sr, valueType, !not, false)).join(' || '));
		}
	} else if (r.type === 'after') {
		return `date ${not ? '<=' : '>'} ${JSON.stringify(r.value)}`;
	} else if (r.type === 'before') {
		return `date ${not ? '>=' : '<'} ${JSON.stringify(r.value)}`;
	} else if (r.type === 'between') {
		const [begin, end] = [JSON.stringify(r.begin), JSON.stringify(r.end)];
		return not ? bracket(`date < ${begin} || date > ${end}`) : `between [${begin} and ${end}]`;
	} else if (r.type === 'begin') {
		return `date ${not ? '<' : '>='} ${JSON.stringify(r.value)}`;
	} else if (r.type === 'end') {
		return `date ${not ? '>' : '<='} ${JSON.stringify(r.value)}`;
	} else if (r.type === 'equal') {
		return `${getPrefix(r.type)} ${not ? '!=' : '=='} ${JSON.stringify(r.value)}`;
	} else if (r.type === 'less') {
		return `${getPrefix(r.type)} ${not ? '>=' : '<'} ${r.value}`;
	} else if (r.type === 'more') {
		return `${getPrefix(r.type)} ${not ? '<=' : '>'} ${r.value}`;
	} else if (r.type === 'min') {
		return `${getPrefix(r.type)} ${not ? '<' : '>='} ${r.value}`;
	} else if (r.type === 'max') {
		return `${getPrefix(r.type)} ${not ? '>' : '<='} ${r.value}`;
	} else if (r.type === 'range') {
		const [min, max] = [JSON.stringify(r.min), JSON.stringify(r.max)];
		return not
			? bracket(`${getPrefix(r.type)} < ${min} || ${getPrefix(r.type)} > ${max}`)
			: `${getPrefix(r.type)} in [${min} to ${max}]`;
	} else if (r.type === 'pattern') {
		return `string ${not ? 'not match' : 'match'} ${r.regex}`;
	} else {
		return '';
	}
}

class ValidateContext {
	level = 0;
	path: string[] = [];
	debug = false;
	detailed = false;
	lenientMode = false;
	log(fn: () => string, pos?: number) {
		if (this.debug) {
			const msg = `${Array(this.level).fill('  ').join('')}${fn()}`;
			if (typeof pos === 'number') {
				this.path.splice(pos, 0, msg);
			} else {
				this.path.push(msg);
			}
		}
	}
	endBlock(name: string, result: boolean, pos: number) {
		if (this.debug) {
			if (!this.detailed) {
				if (!result) {
					const msg = `${Array(this.level).fill('  ').join('')}${name}: ${result}`;
					this.path.splice(pos, 0, msg);
				} else {
					this.path.length = pos;
				}
			} else {
				const msg = `${Array(this.level).fill('  ').join('')}${name}: ${result}`;
				this.path.splice(pos, 0, msg);
			}
		}
	}
	trace() {
		if (this.debug) {
			return this.path.join('\n');
		} else {
			return '';
		}
	}
}

export class Schema {
	readonly toJSON: () => Rule;
	readonly validate: (input: unknown, lenientMode?: boolean) => void;
	trace?: 'error' | 'debug';
	constructor(rule: ValidRule) {
		this.toJSON = () => {
			return rule;
		};
		this.toString = () => {
			return JSON.stringify(rule, null, 2);
		};
		const validator: Validator = createValidator(rule, [
			need.name,
			object.name,
			string.name,
			number.name,
			array.name,
			boolean.name,
			date.name,
			union.name,
			unknown.name,
		]);
		this.validate = (input: unknown, lenientMode = false): void => {
			const context = new ValidateContext();
			context.lenientMode = lenientMode;
			context.debug = !!this.trace;
			context.detailed = this.trace == 'debug';
			try {
				validator(input, context);
			} finally {
				if (context.debug) {
					console.log('------------ TRACE BEGIN ------------');
					console.log(context.trace());
					console.log('------------ TRACE END ------------');
				}
			}
		};
	}
}

export * from './helper';
